# :title:Addicted To Ma.gnolia by John Nunemaker
# = Addicted To Ma.gnolia
#
# This class makes it really simple to access your Ma.gnolia bookmarks 
# using the Ma.gnolia API[http://ma.gnolia.com/support/api/].
#
# Author:: John Nunemaker
# Website:: http://addictedtonew.com
# Last Updated:: June 3, 2006 @ 12:57AM EST
# Copyright:: Copyright (c) 2006 John Nunemaker
# License:: MIT <http://www.opensource.org/licenses/mit-license.php>
#
# == URL's For Downloads and Information
#
# * Ruby Forge: http://rubyforge.org/projects/magnolia/
# * SVN: http://svn.addictedtonew.com/magnolia/
# * Blog Entry: http://addictedtonew.com/archives/155/ruby-magnolia-api-class/
# * Online RDocs: http://addictedtonew.com/examples/magnolia/
#
# == Examples
#
#   my_latest     = Magnolia.find(:person => 'jnunemaker') # will find my last ten bookmarks
#   group_latest  = Magnolia.find(:group => 'webmedia') # will find the last ten bookmarks of the webmedia group
#
#
# == Copyright
#
# Copyright (c) 2006 John Nunemaker
#
# Permission is hereby granted, free of charge, to any person obtaining 
# a copy of this software and associated documentation files (the "Software"), 
# to deal in the Software without restriction, including without limitation 
# the rights to use, copy, modify, merge, publish, distribute, sublicense, 
# and/or sell copies of the Software, and to permit persons to whom the Software 
# is furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 
# DEALINGS IN THE SOFTWARE.
require 'net/http'
require 'rexml/document'
require 'cgi'

class Magnolia
  # Exceptions
  class MissingData < StandardError #:nodoc:
  end
  class RequestError < StandardError #:nodoc:
  end
  
  # This must be called before any of the other methods can be used. It sets a class variable named <tt>api_key</tt>
  # which all the other class methods use when making requests.
  #
  # == Example
  #
  #   Magnolia.new('yourgreatsuperapikey')
  def initialize(api_key)
    Magnolia.set_api_key(api_key)
  end
  
  class << self
    # Sets the api key for all requests
    def set_api_key(api_key)
      @@api_key = api_key
    end
    
    # This method searches for bookmarks that match certain criteria provided by you. Ma.gnolia named this method <tt>bookmarks_find</tt>
    # but I thought it was more appropriately named <tt>search</tt>.
    # 
    # == Examples
    #
    #   latest  = Magnolia.find(:person => 'jnunemaker') # will find my last ten bookmarks
    #   recent  = Magnolia.find(:person => 'jnunemaker', :limit => 20) # will find my last 20 bookmarks
    #   nice    = Magnolia.find(:person => 'jnunemaker', :tags => 'nice', :limit => 5) # will find my last 5 bookmarks tagged 'nice'
    #   
    # Let's get a bit trickier. Below will rescue from a request error such as not enough information or person not found, etc.
    # 
    #   begin
    #     marks = Magnolia.search(:person => 'jnunemaker')
    #     marks.each do |mark|
    #       puts mark.title, " #{mark.id}"
    #     end
    #   rescue Magnolia::RequestError => msg
    #     puts msg
    #   end
    #
    # == Options
    #
    #   :tags   - Multiple tags are treated as an AND search; seperate multiples with commas
    #   :person - Multiple screen names are treated as an ‘OR’ search; seperate multiples with commas
    #   :group  - Multiple groups are treated as an ‘OR’ search; seperate multiples with commas
    #   :rating - Only bookmarks rated this value or higher will be returned
    #   :from   - Only bookmarks created after this date time will be returned
    #   :to     - Only bookmarks created before this date time will be returned
    #   :url    - Only return bookmarks with this url
    #   :limit  - A maximum number of bookmarks to return from search
    #
    # == Return Value
    # 
    # Returns an array of Marks.
    def search(options = {})
      params = { :tags => nil,
            :person => nil,
            :group => nil,
            :rating => nil,
            :from => nil,
            :to => nil,
            :url => nil,
            :limit => 10 }
      params.update(options)

      create_marks_from_xml(make_request('bookmarks_find', params))
    end
    
    # This method is basically like a Ruby on Rails <tt>find</tt> method. Pass it a short name (which is basically an id) 
    # or comma-seperated short names and
    # it will return each mark that is found. Because it is similar to rails <tt>find</tt> method, I have aliased it.
    #
    # == Examples
    #
    #   getone      = Magnolia.get(:id => 'rubyonrails')
    #   getmultiple = Magnolia.get(:id => 'rubyonrails,ruby,someotherid')
    # 
    # If you like Ruby on Rails and think <tt>find</tt> is a more appropriate name for the method, feel free to use it.
    #
    #   getdiff     = Magnolia.find(:id => 'id1')
    #
    # == Options
    #
    #   :id - The short name for one or more bookmarks; seperate multiples with commas (required)
    #
    # == Return Value
    # 
    # Returns an array of Marks.
    def get(marks = {})
      raise MissingData, 'The id must be included in order to know which bookmarks to get.' unless marks.include? :id
      create_marks_from_xml(make_request('bookmarks_get', marks))
    end
    alias :find :get
    
    # == Examples
    #
    #   Magnolia.add(:title        => 'Addicted To New', 
    #               :description  => 'A great site!',
    #               :url          => 'http://addictedtonew.com/',
    #               :private      => 'false',
    #               :tags         => 'cool dude, lame tag, addicting',
    #               :rating       => 5)
    #
    # == Options
    #
    #   :url          - A URL string. Must be valid (required)
    #   :title        - A title string (optional)
    #   :description  - A description string (optional)
    #   :private      - The bookmark’s privacy status; either 'true' or 'false' (optional)
    #   :tags         - A list of tags. This will completely replace any existing tags for each specified bookmark (optional)
    #   :rating       - An integer representing a star rating; 1-5 (optional)
    #
    # == Return Value
    # 
    # Returns an array containing the added mark
    def add(mark = {})
      raise MissingData, 'The url must be included to add a mark.' unless mark.include? :url
      create_marks_from_xml(make_request('bookmarks_add', mark))
    end
    alias :create :add
    
    # == Examples
    #
    #   delone      = Magnolia.delete(:id => 'java')
    #   delmultiple = Magnolia.delete(:id => 'java,perl,someotherid')
    #   deldiff     = Magnolia.destroy(:id => 'someid')
    #
    # == Options
    #
    #   :id - The short name for one or more bookmarks; seperate multiples with commas (required)
    #
    # == Return Value
    # 
    # Nuttin
    def delete(marks = {})
      make_request('bookmarks_delete', marks)
    end
    alias :destroy :delete
    
    # == Examples
    #
    #   Magnolia.update(:id           => 'someid',
    #                 :title        => 'Addicted To New', 
    #                 :description  => 'A great site!',
    #                 :url          => 'http://addictedtonew.com/',
    #                 :private      => 'false',
    #                 :tags         => 'cool dude, lame tag, addicting',
    #                 :rating       => 5)
    #
    # == Options
    #
    #   :id           - The short name for one bookmark (required)
    #   :url          - A URL string. Must be valid (optional)
    #   :title        - A title string (optional)
    #   :description  - A description string (optional)
    #   :private      - The bookmark’s privacy status; either 'true' or 'false' (optional)
    #   :tags         - A list of tags. This will completely replace any existing tags for each specified bookmark (optional)
    #   :rating       - An integer representing a star rating; 1-5 (optional)
    #
    # == Return Value
    # 
    # Returns an array containing the updated mark.
    def update(mark = {})
      raise MissingData, 'The id must be included in order to know which bookmark to update.' unless mark.include? :id
      create_marks_from_xml(make_request('bookmarks_update', mark))
    end
    alias :edit :update
    
    # == Examples
    # 
    #   Magnolia.tags_add(:id => 'rubyonrails', :tags => 'fast,simple')
    # 
    # You can also use add_tags if tags_add seems backwards.
    #   Magnolia.add_tags(:id => 'rubyonrails', :tags => 'fast,simple')
    #
    # == Options
    #
    #   :id     - One or more bookmark short names to which the specified tags should be added
    #   :tags   - Tags to be added to the specified bookmarks
    #
    # == Return Value
    #
    # Nuttin
    def tags_add(mark = {})
      raise MissingData, 'The id must be included in order to know which bookmark to add tags to.' unless mark.include? :id
      raise MissingData, 'The tags must be included that you want to add.' unless mark.include? :tags
      make_request('bookmarks_tags_add', mark)
    end
    alias :add_tags :tags_add
    
    # == Examples
    # 
    #   Magnolia.tags_delete(:id => 'java', :tags => 'cool,fun')
    # 
    # You can also use add_tags if tags_add seems backwards.
    #   Magnolia.add_tags(:id => 'id1', :tags => 'newtag1,newtag2')
    #
    # == Options
    #
    #   :id     - One or more bookmark short names to which the specified tags should be added
    #   :tags   - Tags to be added to the specified bookmarks
    #
    # == Return Value
    #
    # Nuttin
    def tags_delete(mark = {})
      raise MissingData, 'The id must be included in order to know which bookmark to add tags to.' unless mark.include? :id
      raise MissingData, 'The tags must be included that you want to add.' unless mark.include? :tags
      make_request('bookmarks_tags_delete', mark)
    end
    alias :delete_tags :tags_delete
    
    # == Examples
    # 
    #   Magnolia.tags_replace(:id => 'languages', :old => 'java', :new => 'rubyonrails')
    # 
    # You can also use any of these below if you don't like tags_delete
    #   Magnolia.replace_tags(:id => 'languages', :old => 'java', :new => 'rubyonrails')
    #   Magnolia.rename_tags(:id => 'languages', :old => 'java', :new => 'rubyonrails')
    #   Magnolia.tags_rename(:id => 'languages', :old => 'java', :new => 'rubyonrails')
    #
    # == Options
    #
    #   :id     - One or more bookmark short names to which the specified tags should be added
    #   :old    - Existing tag to be replaced
    #   :new    - New tag to replace existing tag
    #
    # == Return Value
    #
    # Nuttin
    def tags_replace(mark = {})
      raise MissingData, 'The id must be included in order to know which bookmark to rename tags for.' unless mark.include? :id
      raise MissingData, 'The old tag is required.' unless mark.include? :old
      raise MissingData, 'The new tag is required.' unless mark.include? :new
      make_request('bookmarks_tags_replace', mark)
    end
    alias :replace_tags :tags_replace
    alias :rename_tags :tags_replace
    alias :tags_rename :tags_replace
    
    # == Examples
    # 
    #   tags = Magnolia.tags_find(:person => 'jnunemaker')
    #   tags.each do |tag|
    #     puts tag.name
    #   end
    # 
    # You can also use the alias of find_tags if it makes more sense to you
    # 
    #   tags = Magnolia.find_tags(:person => 'jnunemaker')
    #
    # == Options
    #
    #   :person - The person whose tags you would like to find
    #
    # == Return Value
    #
    # An array of Tag objects
    def tags_find(mark = {})
      raise MissingData, 'The person (screen name) is required.' unless mark.include? :person
      root = make_request('tags_find', mark)
      create_tags_from_xml(root)
    end
    alias :find_tags :tags_find
    
    private
      # Makes a request to magnolia and processes that request if need be
      def make_request(api_method, params={})
        params.update({ :api_key => @@api_key })
        path            = "/api/rest/1/#{api_method}"
        http            = Net::HTTP.new('ma.gnolia.com', 80)
        response        = http.post(path, hash_to_qs(params))
        raise RequestError, "Cannot make request (#{response.message})!" unless response.message == 'OK'

        # Find out if the request did in fact return xml, raise RequestError if it didn't
        begin
          doc   = REXML::Document.new(response.body)
        rescue
          raise RequestError, 'XML was not returned as the response body.'
        end

        # We now have an xml document so let's see if it contains good stuff or a magnolia error message
        # If magnolia error message, then raise a RequestError
        root  = doc.root
        unless root.attributes['status'] == 'ok'
          error_message  = root.elements["error"].attributes['message']
          error_code     = root.elements["error"].attributes['code']
          raise RequestError, "#{error_code}: #{error_message}"
        end
        root
      end
      
      # creates an array of bookmarks objects from xml
      def create_marks_from_xml(xml)
        marks = []
        REXML::XPath.each(xml, "bookmarks/bookmark") do |mark|
          marks << Mark.from_xml(mark)
        end
        marks
      end
      
      # creates an array of tag objects from xml
      def create_tags_from_xml(xml)
        tags = []
        REXML::XPath.each(xml, "tags/tag") do |tag_xml|
          tags << Tag.from_xml(tag_xml)
        end
        tags
      end

      # Converts a hash to a query string
      def hash_to_qs(h)
        str = ''
        h.each { |key,val| str += "#{key}=#{CGI.escape(val.to_s)}&" unless val.nil? }
        str
      end
  end # class self
  
  # The mark class is used to store information about each bookmark after it is found, created or updated.
  # Each method that is not in the DONT_PROCESS constant returns and array of <tt>Mark</tt> objects.
  class Mark
    def initialize
      @tags = []
    end
    attr_accessor :id, :title, :url, :description
    attr_accessor :screenshot, :rating, :private
    attr_accessor :created, :updated, :owner, :tags
    attr_reader   :img_tag, :a_tag
    
    # Creates an image tag for the screenshot image with the title as the alt text
    def img_tag
      "<img src='#{self.screenshot}' alt='#{self.title}' />"
    end
    
    # Creates a link to the url of the bookmark with the title as the text
    def a_tag
      "<a href='#{self.url}' title='Link to #{self.title}'>#{self.title}</a>"
    end
        
    # This method allows working with bookmarks much like ActiveRecord. 
    #
    # == Examples
    #
    #   mark = Magnolia::Mark.new
    #   mark.title = 'Addicted To New'
    #   mark.url = 'http://addictedtonew.com/'
    #   mark.tags = ['john nunemaker', 'me'] # you can also use comma-seperated list mark.tags = 'john nunemaker, me'
    #   mark.save
    #   puts mark.id # this is the id of the newly created bookmark
    # 
    # You can also use it to update bookmarks like so:
    #
    #   mark = Magnolia::Mark.find(:id => 'someshortname')
    #   mark.title = 'Addicted To New by John Nunemaker'
    #   mark.save
    def save
      if self.id == nil
        # create a new bookmark
        mark    = to_h
        marks   = Magnolia.add(mark)
        self.id = marks[0].id
      else
        # update an existing bookmark
        mark    = to_h
        marks   = Magnolia.update(mark)
      end
    end
    
    # Returns the mark in hash form. This is used in the save method to pass the object in as a hash to the 
    # Magnolia <tt>add</tt> or <tt>update</tt> methods.
    def to_h
      mark = {:id           => self.id,
              :title        => self.title,
              :description  => self.description,
              :url          => self.url,
              :rating       => self.rating,
              :private      => self.private}
      if self.tags.nil?
        mark.update({:tags => ''})
      else
        # allow for tags to be comma seperated list or array
        if self.tags.is_a?(String)
          mark.update({:tags => self.tags})
          # turn the tags into an array
          self.tags = self.tags.split(',')
        elsif self.tags.is_a?(Array)
          mark.update({:tags => self.tags.join(',')})
        end
      end
      mark
    end
    private :to_h
    
    class << self
      # Finds a single bookmark by it's id (short name). If not found raises a Magnolia::RequestError
      def find(id)
        marks = Magnolia.get(:id =>id)
        marks[0]
      end
      
      # Converts xml into a mark
      def from_xml(xml)
        mark             = Mark.new()
        mark.title       = xml.elements['title'].text
        mark.url         = xml.elements['url'].text
        mark.description = xml.elements['description'].text
        mark.screenshot  = xml.elements['screenshot'].text
        mark.id          = xml.attributes['id']
        mark.rating      = xml.attributes['rating']
        mark.private     = xml.attributes['private']
        mark.created     = xml.attributes['created']
        mark.updated     = xml.attributes['updated']
        mark.owner       = xml.attributes['owner']
        REXML::XPath.each(xml, 'tags/tag') do |tag|
          mark.tags << tag.attributes['name']
        end
        mark
      end
    end # class self
  end # class Mark
  
  class Tag
    attr_accessor :name, :count
    
    class << self
      def from_xml(xml)
        t = Tag.new
        t.name = xml.attributes['name']
        t.count = xml.attributes['count']
        t
      end
    end
  end
end # class Magnolia